2025-08-24 객체지향 정글 스터디 ch05, ch06

Scraps

[180] 자주 변경되는 기능이 아니라 안정적인 구조를 따라 역할 책임 협력을 구성하라.

[194] 유스케이스는 하나의 시나리오가 아니라 사용자의 목표와 관련된 모든 시나리오의 집합이라는 사실을 알 수 있다.

안정적인 구조 위에 올라간 불안정한 유스케이스

시스템을 설계할 땐 모든 것은 변한다고 가정을 하여 구조를 설계하는 것이 곧 미래를 예측하여 코딩을 해야 한다는 것은 결코 아니다. 무엇이 자주 바뀌고 무엇이 자주 바뀌지 않을것인지를 판단하여 둘 사이를 구조적으로 분리하여 설계하여야 한다.

정글 핀토스 프로젝트에 앞서 카이스트 권영진 교수님께서 소프트웨어 설계 역사를 한 문장으로 요약한 멋진 디자인 철학을 공유한다:

메커니즘과 정책을 분리하라 - Separation of mechanism and policy(Wikipedia)

이 디자인 철학은 UNIX 시스템을 설계할 당시에 인증/권한 부여, 자원 할당 등을 담당하는 구조적 장치와 시스템이 누가 어떤 권한을 갖는지, 리소스를 어떻게 배분할 것인지의 규칙을 분리하기 위해 발의되었다.

핀토스 프로젝트 중 물리 메모리에 공간이 부족할 경우 가상메모리로 페이지를 Swap하는 방식에 대해서 결정 하는 코드를 작성하는 과제가 있었던 것으로 기억한다. 비록 OS는 객체지향 패러다임을 지원하진 않지만 '함수 포인터'를 사용하여 게으르게 구체적인 구현체(정책)를 매핑할 수 있었다.

이렇게 보면 C언어로도 객체지향적인 코드를 구현할 수도 있는 것 같아보인다. 차이점이라면, 함수포인터를 안전하게 활용할 수 있느냐의 여부에 따라서 갈린 것 같다. Java와 같은 객체지향 언어는 함수포인터를 직접 사용하지 못하도록 막는 대신 컴파일러 단에서 업캐스팅 하도록 강제하여 간접적으로 함수포인터를 사용하도록 안전을 보증하는 것이다!

계약 관점에서 바라본 리스코프 치환원칙

Liskov subsitution principle (Wikipedia)

만약 타입 ST의 서브타입일 경우, T의 임의의 인스턴스 yS의 임의의 객체 x로 어떤 프로그램의 교정 없이도 치환될 수 있어야 한다.

이게 무슨 소리야? what_the_heck.jpg|200 출처: 태왕사신기

사실 리스코프 치환원칙을 건들이는 순간, 타입이론과 공변/반공변에 대한 내용이 튀어나오기 때문에 쉽게 이야기할 수 있는 주제는 아니다. 위키피디아 내용을 더 읽어내려가보자

매개변수 인자타입으로는 반공변(Contravariance), 리턴 타입으로는 공변(Covariance) 을 만족해야 한다.

이건 또 뭔 소리야? what_the_heck.jpg|200 출처: 태왕사신기

Covariance and contravariance (Wikipedia)

다시 위의 명제로 돌아와서..

리턴 타임으로는 공변(Covariance) 을 만족해야 한다.

의 의미는 메서드 오버라이딩시에 하위타입의 리턴타입 T은 상위타입의 리턴타입 T의 하위타입이어야 한다는 뜻이다. 아래 다이어그램에서 볼 수 있듯, 상속 방향이 클래스와 리턴타입 모두 동일한 것을 확인할 수 있다. 그러니까 하위타입 오버라이드 메서드의 리턴타입은 상위타입 메서드의 리턴타입보다 구체적이어야 한다.

Covariant return type

매개변수 인자타입으로는 반공변(Contravariance) 을 만족해야 한다.

의 의미는 메서드 오버라이딩시에 상위타입의 매개변수 타입 T은 하위타입 매개변수 타입 T의 하위타입이어야 한다는 뜻이다. 아래 다이어그램에서 볼 수 있듯, 상속 방향이 클래스와 매개변수 타입이 서로 반대방향인 것을 확인할 수 있다. 그러니까 하위타입 오버라이드 메서드의 매개변수 타입은 상위타입 메서드 매개변수 타입보다 일반적이어야 한다.

Contravariant parameter type

Design By Contract by Bertrand Meyer

Design by contract (Wikipedia)

위 문서는 사용자와 시스템 사이의 계약 관점에서의 치환 가능성에 대해 설명한다. 시스템과 사용자가 상호작용 하기 위해 양측에서 계약을 통해서 서로 지켜야 하는 의무를 지킬 경우 올바른 상호작용을 보장할 수 있다는 내용의 디자인 철학이다.

  1. 서비스 제공자는 제품(혹은 의무)를 고객에게 제공하여야 하며, 고객이 금액(혹은 혜택)을 지불한 대가를 기대해야 합니다.
  2. 고객은 금액(혹은 혜택)을 서비스 제공자에게 지불해야 하며, 서비스 제공자가 제품(혹은 의무)를 제공할 것을 기대해야 합니다.
  3. 양 당사자는 모든 계약에 적용되는 법률 및 규정과 같은 특정 의무를 충족해야 합니다.

이것을 객체지향 세계의 언어로 다시 번역해보자면,

  1. 클라이언트는 서비스의 메서드를 호출할 때 사전조건(Precondition)이 참임을 보장해야 합니다. ⇒ 이것은 클라이언트의 의무이자 서비스의 권리.
  2. 서비스는 메서드가 종료할 때 사후조건(Postcondition)이 참임을 보장해야 합니다. ⇒ 이것은 서비스의 의무이자 클라이언트의 권리.
  3. 양 당사자는 상호작용 전/후에 불변식(Class Invariant)을 만족해야 합니다. ⇒ 클라이언트와 서비스 모두의 의무

더 일반적인 파라미터, 더 구체적인 리턴

뜬금없는 계약 이야기가 나왔지만, 타입계층과 섞어보면 사전조건, 사후조건이 각각 강화되고, 약화되는 방향으로 직관적으로 이해할 수 있게된다. 이때 더 일반적일 수록 조건이 약화되고 더 구체적일수록 조건이 강화된다.

아래의 표는 하위타입 입장에서 바라본 매개변수 타입, 리턴타입의 조건변화에 대하여 정리했다.

관점 Behaviour Subtyping 타입이 (구체적/일반적)? 조건이 (강화/약화)?
매개변수 타입 Contravariance 일반적인 파라메터 사전조건 약화
리턴 타입 Covariance 구체적인 리턴 사후조건 강화

LSP 위반/보장사례

Chat GPT 5 Answer

BoundedStack, 매개변수 타입 반공변 위반사례

interface Stack<T> {
  // Pre: 아무거나 push 가능
  // Post: size = oldSize + 1
  push(item: T): void;
}

class BoundedStack<T> implements Stack<T> {
  constructor(private cap: number, private items: T[] = []) {}

  push(item: T) {
    // ❌ 추가 Precondition: items.length < cap 여야만 push
    // 이는 원래 Stack의 precondition보다 "강화"된 것 → LSP 위반 위험
    if (this.items.length >= this.cap) throw new Error('Full'); 
    this.items.push(item);
  }
}

원래는 "언제든지 Push 가능"이었는데, 서브타입이 "꽉 차지 않았을 때만"으로 사전조건을 강화했다. 이 경우, 기존 클라이언트 코드가 기대하던 호출 가능성이 줄어드는 변화이므로 평소와 똑같이 코드를 호출할 수 없게되어 결국 치환가능성을 위반하게 된다.

해결방법: tryPush(): boolean 같은 새 계약을 정의하거나 인터페이스 자체를 Bounded 계열로 분리할 수 있다.

Animal - Dog 리턴 타입 공변 보장사례

class Animal { speak(): string { return '...'; } }
class Dog extends Animal { speak(): 'woof' { return 'woof' as const; } } // 더 구체적

여기서 잠깐, 'woof' 문자열이 리턴타입이라고? → TS에는 문자열 리터럴 타입 (String Literal Type)이 있어서 문자열 자체가 타입으로 쓰일 수도 있다. 이때 'woof'는 string의 하위타입이다.

하위타입인 Dog는 상위타입 Animalspeak라는 메서드를 오버라이드 하면서 리턴타입을 더 구체적인 타입을 제공하고 있다. 클라이언트 입장에서는 어차피 똑같은 문자열을 기대했을 터이니 계약을 위반한 것이 아니다.

Rectangle - Square 불변식 & 사후조건 위반사례

interface Rectangle {
  get width(): number;
  get height(): number;
  setWidth(w: number): void;   // Post: height는 그대로
  setHeight(h: number): void;  // Post: width는 그대로
}

// LSP를 깨는 구현
class Square implements Rectangle {
  constructor(private _size: number) {}
  get width() { return this._size; }
  get height() { return this._size; }

  setWidth(w: number) {        // ❌ height까지 함께 변경 → 상위 Post조건 위반
    this._size = w;
  }
  setHeight(h: number) {       // ❌ width까지 함께 변경 → 상위 Post조건 위반
    this._size = h;
  }
}

Square#setWidth, Square#setHeight는 상위타입 메서드의 사후조건을 약화한다("다른 변은 그대로"를 더 이상 보장하지 않기때문). 더군다나 상위타입에서 유효하던 가로/세로 독립 조작 조건을 깨뜨렸기 때문에 상위타입의 불변식을 강화한다.

VERDICT

LSP는 단순히 타입의 호환성 여부만을 검사하는 기준이 아니다. 위에서 본 위반사례만 보더라도 컴파일러가 잡아낼 수 없는 논리적인 호환성까지 함께 고려해야 하기 때문이다. 따라서 서브타입의 호환가능여부는 주로 계약에 의한 설계 관점으로 판단하는 습관을 가지는 것이 중요하다.

실무에서는 입력 제약을 슬쩍 강화하거나 결과 보장을 슬쩍 줄여버리면 기존 코드가 깨질 위험이 커진다. 이 관점을 미니 체크리스트로 표현하자면 아래와 같다:

미니 체크리스트 BY Chat GPT 5